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	validator Validator
 25	server    *server.MCPServer
 26}
 27
 28// New creates a new MCP server
 29func New(cfg *config.Config, logger *slog.Logger, planner *planning.Manager) (*Server, error) {
 30	if cfg == nil {
 31		return nil, fmt.Errorf("config cannot be nil")
 32	}
 33	if logger == nil {
 34		return nil, fmt.Errorf("logger cannot be nil")
 35	}
 36	if planner == nil {
 37		return nil, fmt.Errorf("planner cannot be nil")
 38	}
 39
 40	s := &Server{
 41		config:    cfg,
 42		logger:    logger,
 43		planner:   planner,
 44		validator: NewPlanningValidator(cfg),
 45	}
 46
 47	// Create MCP server
 48	mcpServer := server.NewMCPServer(
 49		"planning-mcp-server",
 50		"1.0.0",
 51		server.WithToolCapabilities(true),
 52	)
 53
 54	// Register tools
 55	s.registerTools(mcpServer)
 56
 57	s.server = mcpServer
 58	return s, nil
 59}
 60
 61// registerTools registers all planning tools
 62func (s *Server) registerTools(mcpServer *server.MCPServer) {
 63	// Register set_goal tool
 64	setGoalTool := mcp.NewTool("set_goal",
 65		mcp.WithDescription("Set the initial project goal. Returns error if already set and encourages calling change_goal"),
 66		mcp.WithString("title",
 67			mcp.Required(),
 68			mcp.Description("The goal title"),
 69		),
 70		mcp.WithString("description",
 71			mcp.Required(),
 72			mcp.Description("The goal description"),
 73		),
 74	)
 75	mcpServer.AddTool(setGoalTool, s.handleSetGoal)
 76
 77	// Register change_goal tool
 78	changeGoalTool := mcp.NewTool("change_goal",
 79		mcp.WithDescription("Change an existing project goal. Only use if the operator explicitly requests clearing the board/list/goal and doing something else"),
 80		mcp.WithString("title",
 81			mcp.Required(),
 82			mcp.Description("The new goal title"),
 83		),
 84		mcp.WithString("description",
 85			mcp.Required(),
 86			mcp.Description("The new goal description"),
 87		),
 88		mcp.WithString("reason",
 89			mcp.Required(),
 90			mcp.Description("The reason for changing the goal"),
 91		),
 92	)
 93	mcpServer.AddTool(changeGoalTool, s.handleChangeGoal)
 94
 95	// Register add_tasks tool
 96	addTasksTool := mcp.NewTool("add_tasks",
 97		mcp.WithDescription("Add one or more tasks to work on. Break tasks down into the smallest units of work possible. If there are more than one, use me to keep track of where you are in fulfilling the user's request. Call get_tasks often to stay on track."),
 98		mcp.WithArray("tasks",
 99			mcp.Required(),
100			mcp.Description("Array of tasks to add"),
101			mcp.Items(map[string]any{
102				"type": "object",
103				"properties": map[string]any{
104					"title": map[string]any{
105						"type":        "string",
106						"description": "Task title",
107					},
108					"description": map[string]any{
109						"type":        "string",
110						"description": "Task description (optional)",
111					},
112				},
113				"required": []string{"title"},
114			}),
115		),
116	)
117	mcpServer.AddTool(addTasksTool, s.handleAddTasks)
118
119	// Register get_tasks tool
120	getTasksTool := mcp.NewTool("get_tasks",
121		mcp.WithDescription("Get task list with status indicators. Call this frequently to stay organized and track your progress. Prefer to call with 'all' or 'pending', only 'completed' if unsure, only 'cancelled' or 'failed' if the operator explicitly asks."),
122		mcp.WithString("status",
123			mcp.Description("Filter tasks by status: all, pending, in_progress, completed, cancelled, or failed (default: all)"),
124		),
125	)
126	mcpServer.AddTool(getTasksTool, s.handleGetTasks)
127
128	// Register update_task_statuses tool
129	updateTasksTool := mcp.NewTool("update_task_statuses",
130		mcp.WithDescription("Update the status of one or more tasks. Never cancel tasks on your own. If something doesn't work or there's an error, mark it failed and decide whether to ask the operator for guidance or continue on your own. Usually prefer to ask the operator."),
131		mcp.WithArray("tasks",
132			mcp.Required(),
133			mcp.Description("Array of task updates"),
134			mcp.Items(map[string]any{
135				"type": "object",
136				"properties": map[string]any{
137					"task_id": map[string]any{
138						"type":        "string",
139						"description": "The task ID to update",
140					},
141					"status": map[string]any{
142						"type":        "string",
143						"description": "New status: pending, in_progress, completed, cancelled, or failed",
144					},
145				},
146				"required": []string{"task_id", "status"},
147			}),
148		),
149	)
150	mcpServer.AddTool(updateTasksTool, s.handleUpdateTaskStatuses)
151
152	// Register delete_tasks tool
153	deleteTasksTool := mcp.NewTool("delete_tasks",
154		mcp.WithDescription("Delete one or more tasks by their IDs. Only use if the operator explicitly requests clearing the board/list/goal and doing something else. Otherwise, update statuses to 'cancelled', 'failed', etc. as appropriate. After deletion, respond with the resulting task list."),
155		mcp.WithArray("task_ids",
156			mcp.Required(),
157			mcp.Description("Array of task IDs to delete"),
158			mcp.Items(map[string]any{
159				"type": "string",
160			}),
161		),
162	)
163	mcpServer.AddTool(deleteTasksTool, s.handleDeleteTasks)
164}
165
166// handleSetGoal handles the set_goal tool call
167func (s *Server) handleSetGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
168	s.logger.Info("Received set_goal tool call")
169
170	// Parse request
171	var req SetGoalRequest
172	if err := parseRequest(request.GetArguments(), &req); err != nil {
173		return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
174	}
175
176	// Validate request
177	if err := s.validator.ValidateSetGoalRequest(req); err != nil {
178		return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
179	}
180
181	// Set goal
182	if err := s.planner.SetGoal(req.Title, req.Description); err != nil {
183		s.logger.Error("Failed to set goal", "error", err)
184		return createErrorResult(fmt.Sprintf("Error setting goal: %v", err)), nil
185	}
186
187	goalText := formatGoalText(req.Title, req.Description)
188	response := fmt.Sprintf("Goal \"%s\" saved! You probably want to add one or more tasks now.", goalText)
189	return createSuccessResult(response), nil
190}
191
192// handleChangeGoal handles the change_goal tool call
193func (s *Server) handleChangeGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
194	s.logger.Info("Received change_goal tool call")
195
196	// Parse request
197	var req ChangeGoalRequest
198	if err := parseRequest(request.GetArguments(), &req); err != nil {
199		return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
200	}
201
202	// Validate request
203	if err := s.validator.ValidateChangeGoalRequest(req); err != nil {
204		return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
205	}
206
207	// Change goal
208	if err := s.planner.ChangeGoal(req.Title, req.Description, req.Reason); err != nil {
209		s.logger.Error("Failed to change goal", "error", err)
210		return createErrorResult(fmt.Sprintf("Error changing goal: %v", err)), nil
211	}
212
213	goalText := formatGoalText(req.Title, req.Description)
214	response := fmt.Sprintf("Goal changed to \"%s\" (reason: %s). You probably want to add one or more tasks now.", goalText, req.Reason)
215	return createSuccessResult(response), nil
216}
217
218// handleAddTasks handles the add_tasks tool call
219func (s *Server) handleAddTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
220	s.logger.Info("Received add_tasks tool call")
221
222	// Parse request
223	var req AddTasksRequest
224	if err := parseRequest(request.GetArguments(), &req); err != nil {
225		return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
226	}
227
228	// Validate request
229	if err := s.validator.ValidateAddTasksRequest(req); err != nil {
230		return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
231	}
232
233	// Convert MCP task inputs to planning task inputs
234	tasks := make([]planning.TaskInput, 0, len(req.Tasks))
235	for _, mcpTask := range req.Tasks {
236		tasks = append(tasks, planning.TaskInput{
237			Title:       mcpTask.Title,
238			Description: mcpTask.Description,
239		})
240	}
241
242	// Add tasks
243	result, err := s.planner.AddTasks(tasks)
244	if err != nil {
245		s.logger.Error("Failed to add tasks", "error", err)
246		return createErrorResult(fmt.Sprintf("Error adding tasks: %v", err)), nil
247	}
248
249	// Get the full task list with goal and legend
250	taskList := s.planner.GetTasks()
251
252	var response string
253	if !result.HadExistingTasks {
254		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.\n\n%s", taskList)
255	} else {
256		response = taskList
257	}
258	return createSuccessResult(response), nil
259}
260
261// handleGetTasks handles the get_tasks tool call
262func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
263	s.logger.Info("Received get_tasks tool call")
264
265	// Parse request
266	var req GetTasksRequest
267	if err := parseRequest(request.GetArguments(), &req); err != nil {
268		return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
269	}
270
271	// Validate request
272	if err := s.validator.ValidateGetTasksRequest(req); err != nil {
273		return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
274	}
275
276	// Default status to "all" if empty
277	statusFilter := req.Status
278	if statusFilter == "" {
279		statusFilter = "all"
280	}
281
282	var taskList string
283	if statusFilter == "all" {
284		taskList = s.planner.GetTasks()
285	} else {
286		taskList = s.planner.GetTasksByStatus(statusFilter)
287	}
288
289	return createSuccessResult(taskList), nil
290}
291
292// handleUpdateTaskStatuses handles the update_task_statuses tool call
293func (s *Server) handleUpdateTaskStatuses(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
294	s.logger.Info("Received update_task_statuses tool call")
295
296	// Parse request
297	var req UpdateTaskStatusesRequest
298	if err := parseRequest(request.GetArguments(), &req); err != nil {
299		return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
300	}
301
302	// Validate request
303	if err := s.validator.ValidateUpdateTaskStatusesRequest(req); err != nil {
304		return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
305	}
306
307	// Convert MCP task update inputs to planning task updates
308	updates := make([]planning.TaskUpdate, 0, len(req.Tasks))
309	for _, mcpUpdate := range req.Tasks {
310		updates = append(updates, planning.TaskUpdate{
311			TaskID: mcpUpdate.TaskID,
312			Status: planning.ParseStatus(mcpUpdate.Status),
313		})
314	}
315
316	// Update task statuses
317	if err := s.planner.UpdateTasks(updates); err != nil {
318		s.logger.Error("Failed to update task statuses", "error", err)
319		return createErrorResult(fmt.Sprintf("Error updating task statuses: %v", err)), nil
320	}
321
322	// Return full task list
323	taskList := s.planner.GetTasks()
324	return createSuccessResult(taskList), nil
325}
326
327// handleDeleteTasks handles the delete_tasks tool call
328func (s *Server) handleDeleteTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
329	s.logger.Info("Received delete_tasks tool call")
330
331	// Parse request
332	var req DeleteTasksRequest
333	if err := parseRequest(request.GetArguments(), &req); err != nil {
334		return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
335	}
336
337	// Validate request
338	if err := s.validator.ValidateDeleteTasksRequest(req); err != nil {
339		return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
340	}
341
342	// Delete tasks
343	if err := s.planner.DeleteTasks(req.TaskIDs); err != nil {
344		s.logger.Error("Failed to delete tasks", "error", err)
345		return createErrorResult(fmt.Sprintf("Error deleting tasks: %v", err)), nil
346	}
347
348	// Return full task list
349	taskList := s.planner.GetTasks()
350	return createSuccessResult(taskList), nil
351}
352
353// GetServer returns the underlying MCP server
354func (s *Server) GetServer() *server.MCPServer {
355	return s.server
356}