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 session goal. If this is a new conversation, use me first. Otherwise, use change_goal and include a reason."),
 66		mcp.WithString("title",
 67			mcp.Required(),
 68			mcp.Description("Short, imperative, sentence-case phrase concisely describing the session's overarching goal"),
 69		),
 70		mcp.WithString("description",
 71			mcp.Required(),
 72			mcp.Description("More comprehensive, paragraph-style description capturing additional nuance and detail"),
 73		),
 74	)
 75	mcpServer.AddTool(setGoalTool, s.handleSetGoal)
 76
 77	// Register change_goal tool
 78	changeGoalTool := mcp.NewTool("change_goal",
 79		mcp.WithDescription("Alter the existing session 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		),
 83		mcp.WithString("description",
 84			mcp.Required(),
 85		),
 86		mcp.WithString("reason",
 87			mcp.Required(),
 88			mcp.Description("_Must_ include adequate justification; doesn't have to be long, just complete. If you find the goal requires adjusting, do not just change it on your own. Suggest the change to the user and only call me with their consent. Examples: 'User requested doing x, y, and z' or 'We assumed X was true, but I discovered Y. User consented to the change.'"),
 89		),
 90	)
 91	mcpServer.AddTool(changeGoalTool, s.handleChangeGoal)
 92
 93	// Register add_tasks tool
 94	addTasksTool := mcp.NewTool("add_tasks",
 95		mcp.WithDescription("Add one or more tasks to work on. Break them down into the smallest, complete 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."),
 96		mcp.WithArray("tasks",
 97			mcp.Required(),
 98			mcp.Description("Array of tasks to add"),
 99			mcp.Items(map[string]any{
100				"type": "object",
101				"properties": map[string]any{
102					"title": map[string]any{
103						"type":        "string",
104						"description": "Imperative, sentence-case phrase completely describing the task",
105					},
106					"description": map[string]any{
107						"type":        "string",
108						"description": "If the title isn't enough, use this field to capture additional nuance in a single paragraph. If title is enough, leave this field empty.",
109					},
110				},
111				"required": []string{"title"},
112			}),
113		),
114	)
115	mcpServer.AddTool(addTasksTool, s.handleAddTasks)
116
117	// Register get_tasks tool
118	getTasksTool := mcp.NewTool("get_tasks",
119		mcp.WithDescription("Get the goal and list of tasks. Prefer to call with 'all' or 'pending', only 'completed' if unsure, only 'cancelled' or 'failed' if the operator explicitly asks. The update tool prints the revised list, so calling me isn't always necessary."),
120		mcp.WithString("status",
121			mcp.Description("Filter tasks by status: all, pending, in_progress, completed, cancelled, or failed (default: all)"),
122		),
123	)
124	mcpServer.AddTool(getTasksTool, s.handleGetTasks)
125
126	// Register update_task_statuses tool
127	updateTasksTool := mcp.NewTool("update_task_statuses",
128		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."),
129		mcp.WithArray("tasks",
130			mcp.Required(),
131			mcp.Description("Array of task updates"),
132			mcp.Items(map[string]any{
133				"type": "object",
134				"properties": map[string]any{
135					"task_id": map[string]any{
136						"type": "string",
137					},
138					"status": map[string]any{
139						"type": "string",
140					},
141				},
142				"required": []string{"task_id", "status"},
143			}),
144		),
145	)
146	mcpServer.AddTool(updateTasksTool, s.handleUpdateTaskStatuses)
147
148	// Register delete_tasks tool
149	deleteTasksTool := mcp.NewTool("delete_tasks",
150		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."),
151		mcp.WithArray("task_ids",
152			mcp.Required(),
153			mcp.Description("Array of task IDs to delete, can be one or many"),
154			mcp.Items(map[string]any{
155				"type": "string",
156			}),
157		),
158	)
159	mcpServer.AddTool(deleteTasksTool, s.handleDeleteTasks)
160}
161
162// handleSetGoal handles the set_goal tool call
163func (s *Server) handleSetGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
164	s.logger.Info("Received set_goal tool call")
165
166	// Parse request
167	var req SetGoalRequest
168	if err := parseRequest(request.GetArguments(), &req); err != nil {
169		return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
170	}
171
172	// Validate request
173	if err := s.validator.ValidateSetGoalRequest(req); err != nil {
174		return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
175	}
176
177	// Set goal
178	if err := s.planner.SetGoal(req.Title, req.Description); err != nil {
179		s.logger.Error("Failed to set goal", "error", err)
180		return createErrorResult(fmt.Sprintf("Error setting goal: %v", err)), nil
181	}
182
183	goalText := formatGoalText(req.Title, req.Description)
184	response := fmt.Sprintf("Goal \"%s\" saved! You probably want to add one or more tasks now.", goalText)
185	return createSuccessResult(response), nil
186}
187
188// handleChangeGoal handles the change_goal tool call
189func (s *Server) handleChangeGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
190	s.logger.Info("Received change_goal tool call")
191
192	// Parse request
193	var req ChangeGoalRequest
194	if err := parseRequest(request.GetArguments(), &req); err != nil {
195		return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
196	}
197
198	// Validate request
199	if err := s.validator.ValidateChangeGoalRequest(req); err != nil {
200		return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
201	}
202
203	// Change goal
204	if err := s.planner.ChangeGoal(req.Title, req.Description, req.Reason); err != nil {
205		s.logger.Error("Failed to change goal", "error", err)
206		return createErrorResult(fmt.Sprintf("Error changing goal: %v", err)), nil
207	}
208
209	goalText := formatGoalText(req.Title, req.Description)
210	response := fmt.Sprintf("Goal changed to \"%s\" (reason: %s). You probably want to add one or more tasks now.", goalText, req.Reason)
211	return createSuccessResult(response), nil
212}
213
214// handleAddTasks handles the add_tasks tool call
215func (s *Server) handleAddTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
216	s.logger.Info("Received add_tasks tool call")
217
218	// Parse request
219	var req AddTasksRequest
220	if err := parseRequest(request.GetArguments(), &req); err != nil {
221		return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
222	}
223
224	// Validate request
225	if err := s.validator.ValidateAddTasksRequest(req); err != nil {
226		return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
227	}
228
229	// Convert MCP task inputs to planning task inputs
230	tasks := make([]planning.TaskInput, 0, len(req.Tasks))
231	for _, mcpTask := range req.Tasks {
232		tasks = append(tasks, planning.TaskInput{
233			Title:       mcpTask.Title,
234			Description: mcpTask.Description,
235		})
236	}
237
238	// Add tasks
239	result, err := s.planner.AddTasks(tasks)
240	if err != nil {
241		s.logger.Error("Failed to add tasks", "error", err)
242		return createErrorResult(fmt.Sprintf("Error adding tasks: %v", err)), nil
243	}
244
245	// Get the full task list with goal and legend
246	taskList := s.planner.GetTasks()
247
248	var response string
249	if !result.HadExistingTasks {
250		response = fmt.Sprintf("Tasks added successfully! Get started on your first one once you're ready. If you're updating statuses as you should, you may call `get_tasks` less frequently because `update_task_statuses` prints the revised list.\n\n%s", taskList)
251	} else {
252		response = taskList
253	}
254	return createSuccessResult(response), nil
255}
256
257// handleGetTasks handles the get_tasks tool call
258func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
259	s.logger.Info("Received get_tasks tool call")
260
261	// Parse request
262	var req GetTasksRequest
263	if err := parseRequest(request.GetArguments(), &req); err != nil {
264		return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
265	}
266
267	// Validate request
268	if err := s.validator.ValidateGetTasksRequest(req); err != nil {
269		return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
270	}
271
272	// Default status to "all" if empty
273	statusFilter := req.Status
274	if statusFilter == "" {
275		statusFilter = "all"
276	}
277
278	var taskList string
279	if statusFilter == "all" {
280		taskList = s.planner.GetTasks()
281	} else {
282		taskList = s.planner.GetTasksByStatus(statusFilter)
283	}
284
285	return createSuccessResult(taskList), nil
286}
287
288// handleUpdateTaskStatuses handles the update_task_statuses tool call
289func (s *Server) handleUpdateTaskStatuses(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
290	s.logger.Info("Received update_task_statuses tool call")
291
292	// Parse request
293	var req UpdateTaskStatusesRequest
294	if err := parseRequest(request.GetArguments(), &req); err != nil {
295		return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
296	}
297
298	// Validate request
299	if err := s.validator.ValidateUpdateTaskStatusesRequest(req); err != nil {
300		return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
301	}
302
303	// Convert MCP task update inputs to planning task updates
304	updates := make([]planning.TaskUpdate, 0, len(req.Tasks))
305	for _, mcpUpdate := range req.Tasks {
306		updates = append(updates, planning.TaskUpdate{
307			TaskID: mcpUpdate.TaskID,
308			Status: planning.ParseStatus(mcpUpdate.Status),
309		})
310	}
311
312	// Update task statuses
313	if err := s.planner.UpdateTasks(updates); err != nil {
314		s.logger.Error("Failed to update task statuses", "error", err)
315		return createErrorResult(fmt.Sprintf("Error updating task statuses: %v", err)), nil
316	}
317
318	// Return full task list
319	taskList := s.planner.GetTasks()
320	return createSuccessResult(taskList), nil
321}
322
323// handleDeleteTasks handles the delete_tasks tool call
324func (s *Server) handleDeleteTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
325	s.logger.Info("Received delete_tasks tool call")
326
327	// Parse request
328	var req DeleteTasksRequest
329	if err := parseRequest(request.GetArguments(), &req); err != nil {
330		return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
331	}
332
333	// Validate request
334	if err := s.validator.ValidateDeleteTasksRequest(req); err != nil {
335		return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
336	}
337
338	// Delete tasks
339	if err := s.planner.DeleteTasks(req.TaskIDs); err != nil {
340		s.logger.Error("Failed to delete tasks", "error", err)
341		return createErrorResult(fmt.Sprintf("Error deleting tasks: %v", err)), nil
342	}
343
344	// Return full task list
345	taskList := s.planner.GetTasks()
346	return createSuccessResult(taskList), nil
347}
348
349// GetServer returns the underlying MCP server
350func (s *Server) GetServer() *server.MCPServer {
351	return s.server
352}